Skip to content

Treat a bulk operation as one revertible unit in action_log and rollback - #569

Merged
hyoshi merged 6 commits into
mainfrom
feat/batch-revertible-unit
Aug 11, 2026
Merged

Treat a bulk operation as one revertible unit in action_log and rollback#569
hyoshi merged 6 commits into
mainfrom
feat/batch-revertible-unit

Conversation

@hyoshi

@hyoshi hyoshi commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #549.

Why

Rollback existed but reasoned about one allow-listed operation at a time, so "undo what I did on Monday" was not expressible — after a bulk pass the operator had to reconstruct the member entities by hand. The incident behind this issue ended with 80+ entries deleted in one pass with no confidence the set matched what had been added, and because the revert itself could not be verified, the operator could not rule their own fix out as a variable.

What

1. The boundary is declared, not guessed

A bulk pass is many tool calls and nothing in a single call says which others belong with it. Inferring membership (same minute, same campaign, same tool) would be a heuristic, and a heuristic that silently omits a member re-creates exactly the failure this is meant to prevent. So:

  • mureo_batch_begin (takes a label) / mureo_batch_end (returns the exact member_indices + platforms) / mureo_batch_status.
  • Nesting is refused; closing with none open is refused. Neither is resolved silently.
  • Batch state lives in STATE.json (batches), not process memory, so a host restarting the MCP server mid-pass does not silently stop collecting. Records are kept after close (ended_at) so a batch_id still resolves to the operator's own label later.

2. Membership is stamped in core, at one choke point

Every recording path — native status toggles (native_reversal), mureo_state_action_log_append, and bridged/plugin promotion (plugin_semantics.record_mutation_action_log) — already converges on append_action_log. The stamp is applied there, inside the state lock.

That is the whole cross-platform story: no per-platform code, no plugin ABI hook, no tool-schema change. Threading a batch_id through tool arguments would have worked for native tools only — a bridged tool's input schema belongs to the bridged platform, not to mureo, and many declare additionalProperties: false.

Reversals appended by rollback_apply are explicitly excluded (join_active_batch=False); otherwise reverting a batch would grow the batch it reverts.

3. rollback_plan_get plans a whole batch, gaps included

Pass batch_id instead of index and the response covers every member:

  • coveragefull / partial / none / empty
  • platform_coverage — the same verdict per platform key, because reversibility is not uniform across platforms
  • counts — reversible / reversible_with_caveats / irreversible / nothing_to_reverse / already_reversed / total
  • members[] — each with its index, platform, reversibility and, when it is a gap, the reason
  • apply_order — the reversible indices, newest first

Each member is classified by the existing plan_rollback allow-list, so grouping loosens no guarantee. A batch where 60 of 80 members can be restored reports partial with the other 20 named and explained, before anything is applied — planning is pure and writes nothing.

Honest limits (stated in the docs, not smoothed over)

Platform kind Joins a batch Reversal
Native Google / Meta Status toggles automatically; every other mutation (budget, keywords, exclusions) only if the agent records it with mureo_state_action_log_append Executed for allow-listed operations
Bridged / plugin (plugin:<dist>:<provider>, e.g. Amazon Ads) Automatically, on successful mutation Executed only when the reversal names a registered plugin tool; otherwise reported irreversible with the reason
Hosted connectors (tiktok_ads) Via the agent's own mureo_state_action_log_append — mureo is not in the data path Never executed by mureo. The plan is an accurate manual checklist
Search Console No — its mutations are not recorded in action_log at all n/a

Compatibility

ActionLogEntry.batch_id and StateDocument.batches are both optional and emitted only when present, so an existing STATE.json parses unchanged and gains no new key on the next write (pinned by a round-trip test). batch_id is appended last on the dataclass, preserving positional-constructor compatibility. rollback_plan_get's index form is unchanged; batch_id is an additive alternative (oneOf).

Tests

New tests/test_batch_revertible_unit.py (21 tests), deliberately multi-platform: google_ads and meta_ads (native), tiktok_ads (hosted connector) and plugin:mureo-amazon-ads-bridge:amazon_ads (bridged), including a four-platform batch where each member enters action_log through a different recording path, and a mixed-reversibility batch spanning three platforms.

Not vacuous — two injected regressions, both caught:

  • dropping batch_id propagation in stamp_batch8 failures
  • reporting NOT_SUPPORTED members as REVERSIBLE3 failures, including test_partial_reversibility_is_reported_before_anything_is_applied and test_coverage_is_reported_per_platform

Not in this PR

rollback_apply still takes one index per call; applying a batch is a loop over apply_order. A single "apply the whole batch" result code would have to summarize partial failure into one status, which is the reporting problem this issue exists to remove. No CLI surface for batches — mureo rollback list / show remain entry-by-entry, noted in docs/cli.md.

Docs

docs/mcp-server.md (tool count 205 → 208, new Batch section + batch-plan fields + the per-platform table), docs/strategy-context.md (batches + batch_id schema), docs/architecture.md, docs/cli.md, AGENTS.md, skills/_mureo-shared/SKILL.md + skills/search-term-cleanup/SKILL.md (and their mureo/_data/skills/ mirrors), CHANGELOG.

Gates

ruff check . / black --check . / mypy mureo --ignore-missing-imports clean. python -m pytest: 7795 passed, 12 failed — the identical 12 that fail on origin/main in this environment (9 × test_live_clients.py, plus 3 tool-registry tests that count locally-installed plugin tools). No new failures.

hyoshi added 2 commits August 7, 2026 23:47
mureo had rollback, but it reasoned about one allow-listed operation at a
time, so "undo what I did on Monday" was not expressible: after a bulk pass
the operator had to work out by hand which entries the change set contained.
An unverifiable revert is nearly as bad as no revert — it leaves the operator
unable to rule their own fix out as a variable.

Batch boundary. A bulk pass is many tool calls and nothing in a single call
says which others belong with it, so the boundary is declared, not guessed:
mureo_batch_begin / mureo_batch_end / mureo_batch_status. Inferring it from
timing or target would be a heuristic, and a heuristic that silently omits a
member re-creates the failure this exists to prevent.

Membership is stamped where every recording path already converges
(append_action_log), inside the state lock — not through tool arguments. That
is what makes it platform-agnostic with no per-platform code and no ABI
change: a native status toggle, a hosted-connector mutation an agent records,
and a bridged/plugin call mureo promotes all join the same batch, including
tools whose input schemas mureo does not own. Reversals appended by
rollback_apply are excluded, or reverting a batch would grow it.

rollback_plan_get accepts batch_id and returns a plan covering EVERY member:
coverage (full / partial / none / empty), the same verdict per platform,
per-member reversibility, the reason each irreversible member cannot be
reversed, and an apply_order. Reversibility is not uniform across platforms,
and a plan listing only the reversible members would read as a complete
revert; a batch where 60 of 80 can be restored says so before anything is
applied. Each member is classified by the existing plan_rollback allow-list,
so grouping loosens no guarantee.

Honest limits, documented rather than smoothed over: native mutations other
than status toggles join only when the agent records them; a bridged/plugin
reversal executes only when it names a registered plugin tool; hosted
connectors are never reversed by mureo; Search Console mutations are not in
action_log at all and cannot join a batch today.

STATE.json gains an optional batches array and an optional batch_id per
action_log entry, both emitted only when present — an existing file parses
unchanged and gains no new key on the next write.
Three review findings from PR #569.

Membership was not tamper-proof. mureo_state_action_log_append accepted any
batch_id string: with no batch ever opened, an append could conjure a change
set that rollback_plan_get then reported as legitimate, and an append could
rejoin a batch already closed — making the member_count mureo_batch_end had
reported silently false. A change set whose membership drifts after it was
reported is the "reconstruct it from memory" problem wearing a batch id.

An explicit batch_id is now validated, not trusted: it must name a declared
batch that is still open. The check sits in append_action_log, inside the
lock, so no caller — handler, library user or future recorder — bypasses it.
Closing is final; backfill and import declare their own batch rather than
retrofitting someone else's, which also gives the imported set an honest
label and start time.

A forgotten end swallowed everything after it. The asymmetry matters: a
missed begin yields no batch, which is obvious and harmless, while a missed
end yields a batch that keeps collecting unrelated changes for days and then
reports them, confidently, as one unit. Both halves of the signal are now
there — mureo_batch_status carries a warning for the caller who asks, and one
is appended to every mutating tool result for the caller who forgot and
therefore is not asking. The push half is the one that reaches the person
with the problem. Nothing is auto-closed: a timeout would trade a visible
wrong answer for an invisible one, since entries after it would stop joining
with no one told.

_resolve_path moved from _handlers_mureo_context to _helpers as
resolve_workspace_path. It is the workspace sandbox boundary; a sibling
module reaching into another handler's privates to borrow a security check is
a place for the two to drift.

Tests: batch membership can be neither forged nor grown after close
(including the reviewer's exact reproduction through the MCP tool), staleness
warns and never auto-closes, an unparseable start is not reported as fresh,
and the reminder respects MUREO_DISABLE_BATCH_REMINDER. Also adds a batch
containing native and bridged read-only actions, which pins the known
is_read_only_tool_name defect (native verbs are suffixes, not prefixes) with
the assertion the follow-up PR will flip.
@hyoshi

hyoshi commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Review fixes pushed in 8f2681b. All three MEDIUMs addressed; the pre-existing is_read_only_tool_name bug is deferred to a follow-up PR — measurement below.

1. Membership is now tamper-proof

An explicit batch_id is validated, not trusted: it must name a batch that was declared and is still open. The check lives in append_action_log, inside the state lock, so no caller — MCP handler, library user, or a future recorder — can bypass it.

Closed batches are refused, deliberately. mureo_batch_end reports a member_count the operator keeps; if a later append could still join, that number silently stops being true. Closing is final. For the backfill/import case (#545), the affordance is to declare a batch for the import — which already works and gives the imported set an honest label and start time, rather than retrofitting someone else's.

batch_id on mureo_state_action_log_append is therefore no longer documented as "import/backfill"; it is an explicit assertion that this entry belongs to the batch you believe is open, and it fails loudly when it does not.

The reviewer's exact reproduction is now a test — no begin, arbitrary id, append refused, and rollback_plan_get for that id reports coverage: "empty" with no members.

2. A forgotten end announces itself

Both halves, and I'll say why. mureo_batch_status carries a warning for the caller who asks — but the caller who forgot the batch is open is by definition not asking, and every mutation dispatched meanwhile adds another member. So the thorough half is the one that matters: the warning is appended to the result of every mutating tool call (built-in and plugin), in the same soft-enforcement shape as _maybe_append_strategy_reminder.

Re-emitted per mutation rather than latched once per process — each mutation is a new member wrongly joining, not a repeat of the old problem. Reads do not warn (they add no members). Threshold 24h: a bulk pass is one working session, so a batch open across a day boundary has almost certainly been forgotten. Opt out with MUREO_DISABLE_BATCH_REMINDER=1.

Nothing is auto-closed, per the review instruction — and the code says why: an auto-close would trade a visible wrong answer for an invisible one, since entries after it would silently stop joining and no one would be told. An unparseable started_at returns None rather than "fresh" — an unknown age must not pass for a small one.

3. _resolve_path lifted

Now mureo/mcp/_helpers.py::resolve_workspace_path, imported by name in both _handlers_mureo_context (9 call sites) and _handlers_batch. The private cross-module import is gone; the module docstring records that it is a sandbox boundary and therefore single-sourced. No test referenced the old name.

Pre-existing is_read_only_tool_namefollow-up PR, and it is not the one-liner it looks like

Measured before deciding:

  • Native: 70 of 208 tool names flip under a trailing-verb rule. None contains a write verb, so the native direction is safe.
  • Plugin surface installed here (294 tools): 23 flip, and 13 of those are classified mutating.

That second number is the blocker. server._register_pattern_fallbacks skips register_pattern_fallback_tool(name) when is_read_only_tool_name(name) — so those 13 would be newly exempted from the guardrail money pattern-scan. Among them:

reporting-delete_report              mutating   <- a DELETE that would read as a read
reporting-create_campaign_report     mutating
reporting-create_report              mutating
logly_ads_context_merge_adgroup_list mutating
amc-execute_query                    mutating

A naive trailing-verb rule is therefore wrong, not merely broad: it silently weakens a denial heuristic on real installed tools. The correct fix has to be write-verb-aware (a segment ending _report that also carries delete/create is not a read) and needs its own tests across test_strategy_gate_pattern_fallback.py, test_mcp_plugin_semantics.py, test_rollback.py and test_cli_rollback.py. Separate PR, opening immediately after this one — not an issue, not a note.

Meanwhile the batch test requested in review is in, covering both spellings:

  • campaign_management-list_campaigns (bridged) → nothing_to_reverse — correct today
  • google_ads_campaigns_list (native) → irreversiblemisclassified today, pinned with a comment naming the defect and the assertion the follow-up flips
  • apply_order excludes both either way, so no read is ever offered for reversal

Gates

ruff / black --check / mypy clean. pytest: 7806 passed, 12 failed — the identical 12 that fail on origin/main here. No new failures.

Regression-injected the three new protections, all caught:

Injection Result
ensure_joinable accepts any id 2 failed
closed batches rejoinable 1 failed
stale_batch_warning always None 4 failed

Three cleanups from the second review.

batch_open_hours fell back to datetime.now(timezone.utc), and both production
callers (stale_batch_warning, maybe_build_batch_reminder) omit ``now`` — so
the production path was the only caller outside the one clock seam (#460),
and every test here passes ``now`` explicitly, meaning a drift back to the
wall clock would have gone unnoticed. It now defaults to clock.server_now(),
resolved through the MODULE so monkeypatching the seam still works.

The import is lazy, and has to be: mureo.core.__init__ -> runtime_context ->
state_store -> mureo.context.state -> mureo.context.batch is a real chain, so
a module-level ``from mureo.core import clock`` here raises ImportError on a
partially initialised module. Verified, not assumed.

A test now freezes clock.server_now and asserts the verdict follows it, so
the seam is guarded rather than merely used: reverting to datetime.now fails
that test and nothing else.

Retargets three comments still naming the old private _resolve_path at
mureo/context/batch.py, mureo/context/conversion_overrides.py and
tests/test_mcp_tools_mureo_context.py. (mureo/policy/declarations._resolve_path
is an unrelated function of the same name and is left alone.)

Makes the deferred-fix test docstring self-sufficient, since no issue is being
filed: it now states the defect with the failing call, names all 13 mutating
plugin tools a naive suffix rule would strip of their guardrail money scan
(reporting-delete_report among them), says what a correct fix must do and
which test files it touches, and names the single assertion that flips when
it lands.
@hyoshi

hyoshi commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Cleanups pushed in 4d88064.

MEDIUM — batch staleness now goes through the clock seam

batch_open_hours defaults to clock.server_now(). You were right that the production path was the only caller outside the seam, and that no test would have noticed the drift — so there is one now: test_staleness_follows_the_server_clock_seam freezes mureo.core.clock.server_now and asserts the verdict follows it. Reverting to datetime.now(timezone.utc) fails that test and nothing else (verified by injection).

One deviation from the snippet you gave, forced by the import graph. A module-level from mureo.core import clock in mureo/context/batch.py does not work:

mureo.core.__init__ -> runtime_context -> state_store
                    -> mureo.context.state -> mureo.context.batch
ImportError: cannot import name 'BatchError' from partially initialized module
             'mureo.context.batch' (most likely due to a circular import)

Verified rather than assumed — mureo.core.clock itself imports only datetime, but reaching it executes mureo/core/__init__.py, which closes the cycle. So the import is lazy, inside the now is None branch, and still resolved through the module (clock.server_now()) rather than a bound name, which is what keeps monkeypatch.setattr(mureo.core.clock, "server_now", …) effective. Both the reason and the verification are in the docstring.

No datetime.now remains in the module.

LOW — stale _resolve_path references

Retargeted at mureo/context/batch.py, mureo/context/conversion_overrides.py and tests/test_mcp_tools_mureo_context.py.

Left alone deliberately: mureo/policy/declarations._resolve_path and the reference to it in mureo/amazon_ads/money_paths.py. That is an unrelated function of the same name (argument-path resolution for policy declarations), not the workspace sandbox helper.

LOW — deferred-fix test is now self-sufficient

test_native_read_only_member_is_not_counted_as_a_gap no longer says "lands in its own PR" with nothing to follow. It now records, in the docstring:

  • the defect, with the failing call — is_read_only_tool_name("google_ads_campaigns_list") -> False
  • why the obvious fix is wrong_register_pattern_fallbacks skips the guardrail money pattern-scan for anything reading as a read, and all 13 mutating plugin tools a naive suffix rule would strip are listed by name:
amc-execute_query
logly_ads_context_merge_adgroup_list
reporting-create_campaign_report
reporting-create_inventory_report
reporting-create_product_report
reporting-create_report
reporting-delete_report          <- a DELETE reading as a read
yahoo_ads_create_placement_url_list
yahoo_ads_display_create_placement_url_list
yahoo_ads_display_remove_placement_url_list
yahoo_ads_display_update_placement_url_list
yahoo_ads_remove_placement_url_list
yahoo_ads_update_placement_url_list
  • what a correct fix must do — match a trailing verb only when no write verb appears elsewhere in the same segment
  • which test files it touchestest_strategy_gate_pattern_fallback.py, test_mcp_plugin_semantics.py, test_rollback.py, test_cli_rollback.py
  • the single assertion that flipsby_index[0] becomes NOTHING_TO_REVERSE; the other two and apply_order are unchanged

Someone finding this in six months can act on it without this thread.

Thanks for reproducing the measurement independently — your 26 vs my 23 total flips is the interesting difference, but since the 13 mutating ones match exactly, the conclusion holds either way.

Gates

ruff / black --check / mypy clean. pytest: 7807 passed, 12 failed — the identical 12 that fail on origin/main here. No new failures.

Root cause of the CRITICAL found reviewing the stacked #545 PR.

stamp_batch enumerated ActionLogEntry's fields to produce the batch-stamped
copy, so any field added to the dataclass afterwards was dropped the moment an
entry joined an open batch. join_active_batch defaults to True and both the
import path and mureo_state_action_log_append use the default, so this was the
ordinary path.

The loss was silent, which is what made it dangerous: a dropped field is
indistinguishable downstream from one the caller never set. #545's provenance
fields (origin / external_id) went with it, and since is_external is derived
from origin, an externally-imported entry lost the marker that stops a forged
reversible_params from being planned as a real reversal — turning
NOT_SUPPORTED into SUPPORTED.

Fixed as a class of bug, not an instance: dataclasses.replace(entry,
batch_id=...) carries every field across by construction, so no future field
can opt out of batching by omission. Same defect shape as the agency #193 bug
where update() rebuilt a registry entry field-by-field and dropped archived.

Audited the other six ActionLogEntry construction sites. Five build genuinely
new entries from scratch (native_reversal, plugin_semantics, creative_studio
x2, rollback executor) and have nothing to carry over. The sixth,
state_codec._parse_action_log_entry, enumerates because it must — it maps to
an external JSON schema — and its omission is silent in the same way, so the
round-trip test below covers it too.

Tests are driven off dataclasses.fields(ActionLogEntry), not a hand-written
list, so they cannot rot the way the code did: one asserts stamp_batch changes
only batch_id, the other that every field survives append_action_log with an
open batch and a trip through STATE.json. Adding a field to the dataclass
without adding it to the test's value map fails loudly with an explanatory
message.
@hyoshi

hyoshi commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in 2d5d5c8. The bug is mine and the diagnosis is right — thanks for tracing it back here rather than patching it in #545.

The fix

stamp_batch now uses dataclasses.replace(entry, batch_id=batch.batch_id). Fixed as a class of bug rather than an instance: every field crosses by construction, so no future field can opt out of batching by omission.

The docstring records why, including the concrete cost — that an enumerated rebuild dropped origin / external_id and with them the is_external marker that stops a forged reversible_params on an imported entry from planning as a real reversal.

Audit of the other ActionLogEntry( sites

Seven in the tree. Six are not this defect:

Site Verdict
native_reversal.py:222 New entry from scratch — nothing to carry over
plugin_semantics.py:416 Same
tools_creative_studio.py:505, :738 Same
rollback/executor.py:194 Same
_handlers_mureo_context.py:289 Builds from an untrusted MCP dict; enumerating is correct here — it is the whitelist of what a caller may set
state_codec.py:291 Enumerates, and must — it maps to an external JSON schema, so replace cannot help. Its omission is silent in exactly the same way, so the round-trip test below covers it

stamp_batch was the only rebuild-of-an-existing-entry.

Tests

Both driven off dataclasses.fields(ActionLogEntry), not a hand-written subset:

  • test_stamp_batch_changes_only_batch_id — the pure function, so a failure localizes here rather than in the codec.
  • test_every_field_survives_the_append_round_trip — through stamp_batch, the codec, onto disk and back.

Verified against three separate failure modes by injecting a simulated #545 origin field:

Injection Result
simulated origin + the old enumerating stamp_batch both tests fail (this is the reported bug)
simulated origin, stamp_batch fixed, codec unaware of it round-trip test fails — so the codec half is genuinely covered
simulated origin on the dataclass, absent from the test's value map both fail with ActionLogEntry gained field(s) ['origin'] with no value in _ENTRY_FIELD_VALUES…

That third one is the anti-rot property: the test cannot silently stop covering a field the way the code silently stopped copying one.

One observation, not acted on

The same shape is live one level up. StateDocument is rebuilt field-by-field in five mureo/context/state.py mutators (append_action_log, upsert_campaign, set_report, set_platform_metrics, set_conversion_action_types). That is pre-existing, but this PR is direct evidence it bites: adding batches required hand-threading it into all five, and missing one would have silently closed an open batch on the next campaign upsert.

end_batch and begin_batch already use replace. Converting the other five is mechanical but touches merge semantics with real comments attached, and you said this was the only change — so I have not. Say the word and I will, in its own PR, with a field-list-driven preservation test per mutator.

Gates

ruff / black --check / mypy clean. pytest: 7809 passed, 12 failed — the identical 12 that fail on origin/main here.

One note on that number: an earlier run showed 13, with test_web_handlers.py::TestServeAbout::test_about_unmocked_lists_mureo added. It is not related — that test binds a real local HTTP server with a 2s urlopen timeout, and a sibling worktree was running its own full suite at the same time. It passes in isolation and did not recur on a clean re-run. Flagging rather than quietly reporting the good number.

hyoshi added 2 commits August 12, 2026 06:48
Resolves the conflicts #549 accumulated while main moved:

- state.py: main refactored the STATE.json mutators onto
  dataclasses.replace(), which preserves unlisted fields structurally —
  including the new `batches`. Took main's side for the four mutators
  that were only enumerating fields defensively, and combined the fifth
  (append_action_log) so batch validation/stamping runs on top of
  replace().
- state_codec.py: main's new import-time codec-coverage guard (#576) did
  not know about `batches`, `ActionLogEntry.batch_id`, or `BatchRecord`.
  Both codec halves already handled them; only the declaration was
  missing, which is exactly what the guard is for.
- test_dataclass_field_preservation.py: gave the new fields distinctive
  values so the round-trip actually exercises them.
- Tool count 208 + 213 -> 216 (the 3 batch tools on top of main's 213),
  pinned in test_mcp_server.py and restated in the READMEs, mcp-server.md
  and architecture.md.
- AGENTS.md / architecture.md / CHANGELOG.md: additive union of both sides.
@hyoshi
hyoshi merged commit bb3496b into main Aug 11, 2026
13 checks passed
@hyoshi
hyoshi deleted the feat/batch-revertible-unit branch August 11, 2026 22:01
hyoshi added a commit that referenced this pull request Aug 11, 2026
* feat: treat a bulk operation as one revertible unit (#549)

mureo had rollback, but it reasoned about one allow-listed operation at a
time, so "undo what I did on Monday" was not expressible: after a bulk pass
the operator had to work out by hand which entries the change set contained.
An unverifiable revert is nearly as bad as no revert — it leaves the operator
unable to rule their own fix out as a variable.

Batch boundary. A bulk pass is many tool calls and nothing in a single call
says which others belong with it, so the boundary is declared, not guessed:
mureo_batch_begin / mureo_batch_end / mureo_batch_status. Inferring it from
timing or target would be a heuristic, and a heuristic that silently omits a
member re-creates the failure this exists to prevent.

Membership is stamped where every recording path already converges
(append_action_log), inside the state lock — not through tool arguments. That
is what makes it platform-agnostic with no per-platform code and no ABI
change: a native status toggle, a hosted-connector mutation an agent records,
and a bridged/plugin call mureo promotes all join the same batch, including
tools whose input schemas mureo does not own. Reversals appended by
rollback_apply are excluded, or reverting a batch would grow it.

rollback_plan_get accepts batch_id and returns a plan covering EVERY member:
coverage (full / partial / none / empty), the same verdict per platform,
per-member reversibility, the reason each irreversible member cannot be
reversed, and an apply_order. Reversibility is not uniform across platforms,
and a plan listing only the reversible members would read as a complete
revert; a batch where 60 of 80 can be restored says so before anything is
applied. Each member is classified by the existing plan_rollback allow-list,
so grouping loosens no guarantee.

Honest limits, documented rather than smoothed over: native mutations other
than status toggles join only when the agent records them; a bridged/plugin
reversal executes only when it names a registered plugin tool; hosted
connectors are never reversed by mureo; Search Console mutations are not in
action_log at all and cannot join a batch today.

STATE.json gains an optional batches array and an optional batch_id per
action_log entry, both emitted only when present — an existing file parses
unchanged and gains no new key on the next write.

* fix: harden batch membership and signal a forgotten batch (#549 review)

Three review findings from PR #569.

Membership was not tamper-proof. mureo_state_action_log_append accepted any
batch_id string: with no batch ever opened, an append could conjure a change
set that rollback_plan_get then reported as legitimate, and an append could
rejoin a batch already closed — making the member_count mureo_batch_end had
reported silently false. A change set whose membership drifts after it was
reported is the "reconstruct it from memory" problem wearing a batch id.

An explicit batch_id is now validated, not trusted: it must name a declared
batch that is still open. The check sits in append_action_log, inside the
lock, so no caller — handler, library user or future recorder — bypasses it.
Closing is final; backfill and import declare their own batch rather than
retrofitting someone else's, which also gives the imported set an honest
label and start time.

A forgotten end swallowed everything after it. The asymmetry matters: a
missed begin yields no batch, which is obvious and harmless, while a missed
end yields a batch that keeps collecting unrelated changes for days and then
reports them, confidently, as one unit. Both halves of the signal are now
there — mureo_batch_status carries a warning for the caller who asks, and one
is appended to every mutating tool result for the caller who forgot and
therefore is not asking. The push half is the one that reaches the person
with the problem. Nothing is auto-closed: a timeout would trade a visible
wrong answer for an invisible one, since entries after it would stop joining
with no one told.

_resolve_path moved from _handlers_mureo_context to _helpers as
resolve_workspace_path. It is the workspace sandbox boundary; a sibling
module reaching into another handler's privates to borrow a security check is
a place for the two to drift.

Tests: batch membership can be neither forged nor grown after close
(including the reviewer's exact reproduction through the MCP tool), staleness
warns and never auto-closes, an unparseable start is not reported as fresh,
and the reminder respects MUREO_DISABLE_BATCH_REMINDER. Also adds a batch
containing native and bridged read-only actions, which pins the known
is_read_only_tool_name defect (native verbs are suffixes, not prefixes) with
the assertion the follow-up PR will flip.

* fix: route batch staleness through the server clock seam (#549 review)

Three cleanups from the second review.

batch_open_hours fell back to datetime.now(timezone.utc), and both production
callers (stale_batch_warning, maybe_build_batch_reminder) omit ``now`` — so
the production path was the only caller outside the one clock seam (#460),
and every test here passes ``now`` explicitly, meaning a drift back to the
wall clock would have gone unnoticed. It now defaults to clock.server_now(),
resolved through the MODULE so monkeypatching the seam still works.

The import is lazy, and has to be: mureo.core.__init__ -> runtime_context ->
state_store -> mureo.context.state -> mureo.context.batch is a real chain, so
a module-level ``from mureo.core import clock`` here raises ImportError on a
partially initialised module. Verified, not assumed.

A test now freezes clock.server_now and asserts the verdict follows it, so
the seam is guarded rather than merely used: reverting to datetime.now fails
that test and nothing else.

Retargets three comments still naming the old private _resolve_path at
mureo/context/batch.py, mureo/context/conversion_overrides.py and
tests/test_mcp_tools_mureo_context.py. (mureo/policy/declarations._resolve_path
is an unrelated function of the same name and is left alone.)

Makes the deferred-fix test docstring self-sufficient, since no issue is being
filed: it now states the defect with the failing call, names all 13 mutating
plugin tools a naive suffix rule would strip of their guardrail money scan
(reporting-delete_report among them), says what a correct fix must do and
which test files it touches, and names the single assertion that flips when
it lands.

* feat: import externally-made changes into action_log (#545)

Every guarantee mureo offers hung off mureo having *made* the change. An
operator working in a platform's own UI — normal professional work, not misuse
— never went through StrategyPolicyGate, never reached action_log, never got an
observation_due, and never appeared in /daily-check's evidence step. The
consequence was not a thin log but a blind one: mureo could not tell "nothing
happened" from "something happened that I cannot see". In the originating
incident that gap cost roughly six days of failed diagnosis, because nothing
connected "delivery died" to "exclusions were added".

mureo_external_changes_import polls each configured platform's change feed and
appends what mureo did not do. /daily-check runs it at step 2b, before it diffs
or diagnoses anything. Idempotent — importing the same change twice is a no-op.

Observed is not performed. ActionLogEntry gains origin / external_id /
occurred_at (optional, emitted only when set, so an existing STATE.json parses
unchanged and gains no new key). An imported entry can never be mistaken for
one mureo dispatched, and plan_rollback refuses every external entry before any
other check — even one carrying a well-formed reversal hint. mureo never
captured the prior value, so a reversal built from such a hint is a fresh change
dressed as a restoration. A batch mixing the two therefore reports partial
coverage rather than promising a revert it could only half deliver.

The observation window anchors on when the change happened, not on when mureo
noticed: a change made three weeks ago lands already past due. metrics_at_action
is deliberately unset — mureo was not there, and a synthesised baseline would
have mureo_outcome_evaluate score a delta against a "before" that never existed.

mureo's own changes are not double-counted. Every change mureo dispatches also
appears in the feed, and the feed's attribution fields cannot separate them:
user_email is the same OAuth identity either way, and GOOGLE_ADS_API covers
every API tool on the account. The discriminator is mureo's own log — same
platform, same target identity, within 10 minutes. Where identity is missing the
match cannot be made and the change is imported as external: an over-import is
visible and correctable, an over-attribution silently swallows a real UI edit.

Missing coverage is reported, never smoothed over. Every configured platform
appears in the response. No feed returns change_import_unavailable_for_<platform>
(the analytics_not_available_for_<platform> contract); a failed feed returns
error. Neither means "no changes" — the absence of a change feed is not evidence
of innocence. A capped response returns truncated: true, because change_event
returns at most 100 rows with no paging and retains ~30 days: history cannot be
reconstructed after the fact, only captured continuously.

Coverage today: Google Ads is read. Meta Ads is not — the Ad Account Activity
edge exists on Meta's side and mureo ships no client for it. Amazon (bridge),
Yahoo, LINE and SmartNews (plugins) can opt in through the ABI hook and do not
today. TikTok is a hosted connector outside mureo's data path entirely; a skill
records what it reads there via mureo_state_action_log_append with
origin: "external". The full table is in docs/change-import.md.

ABI hook: a new ChangeFeedProvider Protocol in a new mureo.change_feeds
entry-point group. A new Protocol because adding a method to a runtime_checkable
one de-registers every published implementation (ABI-stability §4); a new group
so a bridge wanting only a change feed need not stub four analytics methods it
will never implement. Installed plugins are unaffected in every way.

Google Ads' change_event query additionally selects resource_name, client_type,
campaign, ad_group and changed_resource_name. Purely additive — every field
google_ads_change_history_list returned before is unchanged.

Closes #545

* fix: close three attribution and honesty gaps in change import (#545 review)

Attribution compared platform, identity and a 10-minute window but not the
KIND of change, so mureo pausing campaign 111 swallowed the operator's budget
edit on campaign 111 four minutes later. Identity-only matching went straight
through the principle the module's own docstring states — an over-attribution
silently discards a real UI edit — and manual work overlapping mureo-driven
work on one campaign is the common case after onboarding, not a corner.

A match now also requires the same kind of change, from a small shared
vocabulary (status / budget / bid / criterion / ad / ad_group / campaign)
derived independently from each side: changed_fields before resource_type on
the feed side (a budget edit arrives as CAMPAIGN + changed_fields=[campaign_budget]
as often as CAMPAIGN_BUDGET), tool name matched verb-before-noun on the log
side. Both sides must yield a kind and the two must be equal — "unknown matches
anything" would restore the old behaviour for every action mureo cannot
classify. A definite create-vs-remove disagreement additionally refutes a
match, catching mureo removing a negative keyword while the operator adds one;
it only refutes, never confirms, since a *_update tool may be an upsert.

What kind matching still cannot discriminate — two same-kind changes on one
target inside the window, free-text action names, sub-kind detail — is now
written down in docs/change-import.md rather than left as an unmet claim.

BYOD reported IMPORTED with nothing imported. ChangeImportStatus's own
docstring says IMPORTED means the feed ran, blind_spots collects only
UNAVAILABLE and ERROR, and daily-check step 2b tells the agent a platform
absent from blind_spots was looked at — so a mode with no change history at
all was reported as checked and quiet. That is the exact collapse this feature
removes, reintroduced by the feature. ChangeFeedResult gains an optional
unavailable_reason: a registered feed that could not answer for this account or
mode sets it, and the importer maps it to UNAVAILABLE. Additive with a default,
so no published feed changes. The BYOD test asserted only on notes and passed
regardless; it now asserts the status, and a second test carries the signal end
to end into blind_spots.

Imported changes no longer join an open batch. A batch is the operator's
declared change set; a change mureo merely observed is not something they did
through mureo, and letting it join dropped the whole batch's rollback coverage
to partial over an unrelated UI edit that happened to be polled while the batch
was open. Same reasoning as the rollback executor's own rollback_of exclusion.

Also documents, next to ATTRIBUTION_WINDOW_MINUTES, that widening the window
can only add attributions — the least certain ones, failing silently — while
narrowing it can only add over-imports, which the operator sees.

* fix: stamp_batch must not rebuild ActionLogEntry field-by-field (#549)

Root cause of the CRITICAL found reviewing the stacked #545 PR.

stamp_batch enumerated ActionLogEntry's fields to produce the batch-stamped
copy, so any field added to the dataclass afterwards was dropped the moment an
entry joined an open batch. join_active_batch defaults to True and both the
import path and mureo_state_action_log_append use the default, so this was the
ordinary path.

The loss was silent, which is what made it dangerous: a dropped field is
indistinguishable downstream from one the caller never set. #545's provenance
fields (origin / external_id) went with it, and since is_external is derived
from origin, an externally-imported entry lost the marker that stops a forged
reversible_params from being planned as a real reversal — turning
NOT_SUPPORTED into SUPPORTED.

Fixed as a class of bug, not an instance: dataclasses.replace(entry,
batch_id=...) carries every field across by construction, so no future field
can opt out of batching by omission. Same defect shape as the agency #193 bug
where update() rebuilt a registry entry field-by-field and dropped archived.

Audited the other six ActionLogEntry construction sites. Five build genuinely
new entries from scratch (native_reversal, plugin_semantics, creative_studio
x2, rollback executor) and have nothing to carry over. The sixth,
state_codec._parse_action_log_entry, enumerates because it must — it maps to
an external JSON schema — and its omission is silent in the same way, so the
round-trip test below covers it too.

Tests are driven off dataclasses.fields(ActionLogEntry), not a hand-written
list, so they cannot rot the way the code did: one asserts stamp_batch changes
only batch_id, the other that every field survives append_action_log with an
open batch and a trip through STATE.json. Adding a field to the dataclass
without adding it to the test's value map fails loudly with an explanatory
message.

* test: pin provenance through the batch-stamping path, and name the residual limit

Completes the verification the stamp_batch fix (2d5d5c8) was needed for, on
the path that is actually at risk. The importer passes join_active_batch=False
so it never reaches stamp_batch at all; a hosted connector does, because mureo
holds no credentials for TikTok and an agent records what it read from the
connector's own change history through mureo_state_action_log_append — the
ordinary append path, where joining the open batch is the default.

Three tests cover that chain rather than the stamping function: with a batch
open, an appended external entry keeps origin / external_id / occurred_at and
still reports is_external; plan_rollback returns NOT_SUPPORTED on a forged
allow-listed reversal hint; and the batch plan reports the member as a gap
rather than as covered. Reverting stamp_batch to the enumerated rebuild fails
all three with the original symptoms (origin None, SUPPORTED, coverage full),
so they catch the CRITICAL and not merely its neighbourhood.

#549's round-trip guard is derived from dataclasses.fields(ActionLogEntry) and
correctly refused the three new fields until values were supplied; added, with
the note that these are the fields the enumerated version actually dropped.

Documentation. The residual over-attribution — a hand edit to the SAME setting
on the SAME entity within the attribution window of a mureo change is
indistinguishable from mureo's own and is silently lost — was accurate but
filed as one bullet among three, which is the wrong weight for the one case
that fails in the expensive direction. It is the mixed-operation pattern of
the incident behind #545, not an exotic shape.

It now has its own section in docs/change-import.md, led by what the operator
should actually do (wait out the ten minutes before editing that entity's same
setting by hand, or say what was changed so the record does not depend on the
import), and a matrix showing how narrow the case is — a different entity, or
a different setting on the same entity, imports normally. The same advice is
in the _mureo-shared skill, where an agent will read it and can raise it with
the operator at the moment it applies. The two things that fail toward
over-import instead are kept, demoted to their own list.

* fix: require the SAME target, not an overlapping one, before attributing (#545 review)

_identity_pairs intersected the two identity sets and accepted a non-empty
result. That is "some slot agrees", not "no populated slot disagrees", and it
inverted the module's stated bias at the identity layer: where identity could
not be resolved it fell back to the coarsest shared field and attributed —
failing toward the silent swallow the design exists to avoid.

Two reproductions, both discarding a real operator edit:

- mureo updates keyword kw-A's bid; four minutes later the operator edits
  kw-B's bid in the same campaign. entity_id disagreed outright but was never
  consulted, because the shared campaign_id alone satisfied the intersection.
- mureo pauses one ad; the operator pauses the whole campaign. ad_id is simply
  absent on a campaign-level feed row, so the match fell back to the shared
  campaign_id and a small mureo action swallowed a much larger operator one.

Two rules replace the intersection. No slot populated on both sides may
disagree — rejecting on any disagreement rather than accepting on any
agreement. And both sides must name their target at the same specificity
(entity_id > ad_id > campaign_id, mureo's existing canonical-target
precedence): a target and its container are not a match, and an asymmetry
there is unresolved, which must mean import.

Requiring equal specificity forces the feed side to speak mureo's identity
language, so the Google adapter now names ONE canonical target per row: an
ad-level row names the ad, parsed out of changed_resource_name, and not its
parent ad group. Without that a feed row would look strictly more specific
than mureo's own record of the same change and every ad-level action would
re-import as external on each run. This is the rule
plugin_semantics.extract_mutation_identity already states for plugin
mutations. A row whose ad cannot be resolved names no sub-campaign target at
all rather than falling back to the ad group.

Tests: both reproductions, both asymmetry directions, and a nine-case identity
matrix over same/different campaign, same/different entity, entity present on
one side only, and inside/outside the window. Neither reproduction had a test —
the suite only covered a different campaign entirely, which is how this
shipped.

docs/change-import.md claimed "Different entity, any setting, any time | Yes",
which was false. The table now enumerates sibling, broader and narrower
targets, all correctly imported, and says which row of an earlier version was
wrong. The ATTRIBUTION_WINDOW_MINUTES note is re-checked: its asymmetry
argument holds, and the window now multiplies only the documented
same-entity/same-setting case rather than every edit inside a touched campaign.

* fix: correct an invented proto field, and name criteria and ads as their own targets (#545 review)

Three defects, one of them in code that already shipped.

1. changed_resource_name does not exist. The ChangeEvent proto field is
change_resource_name — no "d". Verified against the vendored SDK (google-ads
29.2.0, v23): the descriptor lists change_resource_name and hasattr for the
other spelling is False. It was in the GAQL SELECT, which the API would
reject, and in map_change_event, where getattr silently yields "". So
google_ads_change_history_list — a tool that ships today — was likely inert in
production, and the "name the ad, not its ad group" identity work never fired
against real traffic.

The whole mapper was audited field by field against the descriptor; that was
the only wrong name. It survived because every test built row dicts by hand
and bypassed the mapper, and because MagicMock answers hasattr for any name
and returns a truthy mock, so a misspelling passes every assertion. Two tests
now close that: one drives map_change_event with a real ChangeEvent (a
protobuf raises on a name it does not have), and one asserts every emitted key
against the descriptor. A third asserts the same for the GAQL SELECT, which
nothing checked at all — _search is mocked in every test there, so the query
string itself was never read.

2. AD rows lost their identity. _ad_id_from_changed_resource parsed only
adGroupAds/<ag>~<ad>, but AdService — which google_ads_ads_update reports
through — names ads customers/<cid>/ads/<id>, with no ad-group segment and no
"~". Those rows came back campaign-only, so every creative edit would
re-import as external on every run: the exact effect the identity work exists
to remove, on the other resource type. Both shapes are accepted now.

3. The flagship scenario was not closed. "mureo edits kw-A's bid, the operator
edits kw-B's" needs criterion-level identity on both sides, and neither had
it. extract_mutation_identity discarded criterion_id and fell through to the
parent ad_group_id, and the feed named the ad group too — so two keywords in
one ad group were one target and the operator's edit was still discarded.
criterion_id (and criterionId) now outrank ad_group_id at capture, criterion
rows name the criterion rather than their parent, and _mureo-shared tells the
agent to pass the criterion id for keyword mutations.

Native keyword mutations are still recorded only when the agent makes that
call — nothing enforces the granularity — so docs/change-import.md now says
the sibling-entity row holds only when both sides named the sibling, instead
of claiming it unconditionally.

Also: entity_type was documented as part of ExternalChange's identity and
never compared, so an ad_group numbered 999 matched a keyword numbered 999.
Unlikely on Google and Meta, guaranteed by nothing for a plugin declaring its
own entity_type. The generic slot now compares type and id together.

* test: sweep every GAQL builder against the proto descriptors (#545 review)

Tonight's shipped defect — change_event.changed_resource_name, a field that
does not exist — came from a gap rather than a typo: _search is mocked in
every test that touches a GAQL builder, so no test ever read a query string,
and the mapper reading the same wrong name returns "" instead of raising. The
one query that was caught got a guard last commit; this is the sweep for the
rest.

tests/test_gaql_field_names.py extracts every SELECT in mureo/ and resolves
each dotted field path against the vendored v23 protobuf descriptors, walking
nested messages. Both the query list and the field list are derived — from the
tree and from the SDK — because a hardcoded expectation goes stale silently,
which is the failure mode being fixed.

RESULT: CLEAN. 56 queries across 16 files, 360 field references, zero invalid.
There is no second instance of the shipped bug.

Two things had to be fixed in the checker before that result meant anything,
and both are now asserted rather than assumed. It first reported 18 failures
that were all false: proto-plus mangles names colliding with Python builtins
(type -> type_) while GAQL uses the unmangled API name, so asset.type is
correct and the checker was wrong. And it initially missed queries built from
implicitly concatenated string literals, leaving 27 field references across
two files unchecked while still reporting "clean" — test_extraction_covers_-
every_query now pins the query count against an independent count of FROM
clauses, and test_every_query_selects_at_least_one_field catches a query whose
fields all failed to parse.

AD_GROUP_BID_MODIFIER joins the criterion resource types. It uses the same
composite parentId~criterionId shape, so omitting it left those rows
collapsing onto the parent ad group — the defect the criterion entries exist
to fix, left in place for one type. Not reachable through mureo's own tools
(device bids go through CampaignCriterionService), but an operator's UI edit
is, and this is a feed of changes mureo did not make.

Every resource-name segment the adapter parses is now asserted against the
SDK's own *_path builders rather than hardcoded, since a wrong segment yields
"no identity" silently rather than raising.

plugin_semantics documents the undeclared-identity fallback as a known
property: its guesses carry hardcoded Google-shaped entity_type labels applied
to third-party arguments that merely share a spelling. Inherent to guessing,
unreachable through mureo's own built-ins, and avoidable by declaring
identity — which is the supported route.

* docs: drop the #549 paragraph the merge duplicated in architecture.md

* fix: drop the _batch_record_to_dict copy the merge kept twice
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Treat a bulk operation as one revertible unit in action_log and rollback

1 participant